/*******************************************************************************
 * Copyright (c) 2021 Red Hat Inc. and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Lucas Bullen (Red Hat Inc.) - initial API and implementation
 *******************************************************************************/
package org.eclipse.test;

import java.io.IOException;
import java.io.OutputStream;
import java.io.Reader;
import java.util.Date;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;

import org.apache.tools.ant.taskdefs.optional.junitlauncher.TestResultFormatter;
import org.apache.tools.ant.util.DOMElementWriter;
import org.apache.tools.ant.util.DateUtils;
import org.junit.platform.commons.util.ExceptionUtils;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.engine.TestSource;
import org.junit.platform.engine.reporting.ReportEntry;
import org.junit.platform.engine.support.descriptor.ClassSource;
import org.junit.platform.launcher.TestIdentifier;
import org.junit.platform.launcher.TestPlan;

/**
 * A {@link TestResultFormatter} which generates an XML report of the tests. The
 * generated XML reports conforms to the schema of the XML that was generated by
 * the {@code junit} task's XML report formatter and can be used by the
 * {@code junitreport} task
 */
public class LegacyXmlResultFormatter extends AbstractJUnitResultFormatter {

	private static final double ONE_SECOND = 1000.0;

	OutputStream outputStream;
	final Map<TestIdentifier, Stats> testIds = new ConcurrentHashMap<>();
	final Map<TestIdentifier, Optional<String>> skipped = new ConcurrentHashMap<>();
	final Map<TestIdentifier, Optional<Throwable>> failed = new ConcurrentHashMap<>();
	final Map<TestIdentifier, Optional<Throwable>> errored = new ConcurrentHashMap<>();
	final Map<TestIdentifier, Optional<Throwable>> aborted = new ConcurrentHashMap<>();

	TestPlan testPlan;
	long testPlanStartedAt = -1;
	long testPlanEndedAt = -1;
	final AtomicLong numTestsRun = new AtomicLong(0);
	final AtomicLong numTestsFailed = new AtomicLong(0);
	final AtomicLong numTestsErrored = new AtomicLong(0);
	final AtomicLong numTestsSkipped = new AtomicLong(0);
	final AtomicLong numTestsAborted = new AtomicLong(0);

	@Override
	public void testPlanExecutionStarted(final TestPlan plan) {
		this.testPlan = plan;
		this.testPlanStartedAt = System.currentTimeMillis();
	}

	@Override
	public void testPlanExecutionFinished(final TestPlan plan) {
		this.testPlanEndedAt = System.currentTimeMillis();
		// format and print out the result
		try {
			new XMLReportWriter().write();
		} catch (IOException | XMLStreamException e) {
			handleException(e);
			return;
		}
	}

	@Override
	public void dynamicTestRegistered(final TestIdentifier testIdentifier) {
		// nothing to do
	}

	@Override
	public void executionSkipped(final TestIdentifier testIdentifier, final String reason) {
		final long currentTime = System.currentTimeMillis();
		this.numTestsSkipped.incrementAndGet();
		this.skipped.put(testIdentifier, Optional.ofNullable(reason));
		// a skipped test is considered started and ended now
		final Stats stats = new Stats(testIdentifier, currentTime);
		stats.endedAt = currentTime;
		this.testIds.put(testIdentifier, stats);
	}

	@Override
	public void executionStarted(final TestIdentifier testIdentifier) {
		final long currentTime = System.currentTimeMillis();
		this.testIds.putIfAbsent(testIdentifier, new Stats(testIdentifier, currentTime));
		if (testIdentifier.isTest()) {
			this.numTestsRun.incrementAndGet();
		}
	}

	@Override
	public void executionFinished(final TestIdentifier testIdentifier, final TestExecutionResult testExecutionResult) {
		final long currentTime = System.currentTimeMillis();
		final Stats stats = this.testIds.get(testIdentifier);
		if (stats != null) {
			stats.endedAt = currentTime;
		}
		switch (testExecutionResult.getStatus()) {
		case SUCCESSFUL: {
			break;
		}
		case ABORTED: {
			this.numTestsAborted.incrementAndGet();
			this.aborted.put(testIdentifier, testExecutionResult.getThrowable());
			break;
		}
		case FAILED: {
			Optional<Throwable> throwableOptional = testExecutionResult.getThrowable();
			if (throwableOptional.isPresent() && throwableOptional.get() instanceof AssertionError) {
				this.numTestsFailed.incrementAndGet();
				this.failed.put(testIdentifier, testExecutionResult.getThrowable());
			} else {
				this.numTestsErrored.incrementAndGet();
				this.errored.put(testIdentifier, testExecutionResult.getThrowable());
			}
			break;
		}
		}
	}

	@Override
	public void reportingEntryPublished(final TestIdentifier testIdentifier, final ReportEntry entry) {
		// nothing to do
	}

	@Override
	public void setDestination(final OutputStream os) {
		this.outputStream = os;
	}

	private static final class Stats {
		final long startedAt;
		long endedAt;

		Stats(final TestIdentifier testIdentifier, final long startedAt) {
			this.startedAt = startedAt;
		}
	}

	@Override
	public void setUseLegacyReportingName(final boolean useLegacyReportingName) {
		// do nothing
	}

	private final class XMLReportWriter {

		private static final String ELEM_TESTSUITE = "testsuite";
		private static final String ELEM_PROPERTIES = "properties";
		private static final String ELEM_PROPERTY = "property";
		private static final String ELEM_TESTCASE = "testcase";
		private static final String ELEM_SKIPPED = "skipped";
		private static final String ELEM_FAILURE = "failure";
		private static final String ELEM_ERROR = "error";
		private static final String ELEM_ABORTED = "aborted";
		private static final String ELEM_SYSTEM_OUT = "system-out";
		private static final String ELEM_SYSTEM_ERR = "system-err";

		private static final String ATTR_CLASSNAME = "classname";
		private static final String ATTR_NAME = "name";
		private static final String ATTR_VALUE = "value";
		private static final String ATTR_TIME = "time";
		private static final String ATTR_TIMESTAMP = "timestamp";
		private static final String ATTR_NUM_ABORTED = "aborted";
		private static final String ATTR_NUM_FAILURES = "failures";
		private static final String ATTR_NUM_ERRORS = "errors";
		private static final String ATTR_NUM_TESTS = "tests";
		private static final String ATTR_NUM_SKIPPED = "skipped";
		private static final String ATTR_MESSAGE = "message";
		private static final String ATTR_TYPE = "type";

		public XMLReportWriter() {
			// TODO Auto-generated constructor stub
		}

		void write() throws XMLStreamException, IOException {
			final XMLStreamWriter writer = XMLOutputFactory.newFactory().createXMLStreamWriter(outputStream, "UTF-8");
			try {
				writer.writeStartDocument();
				writeTestSuite(writer);
				writer.writeEndDocument();
			} finally {
				writer.close();
			}
		}

		void writeTestSuite(final XMLStreamWriter writer) throws XMLStreamException, IOException {
			// write the testsuite element
			writer.writeStartElement(ELEM_TESTSUITE);
			final String testsuiteName = determineTestSuiteName();
			writer.writeAttribute(ATTR_NAME, testsuiteName);
			// time taken for the tests execution
			writer.writeAttribute(ATTR_TIME, String.valueOf((testPlanEndedAt - testPlanStartedAt) / ONE_SECOND));
			// add the timestamp of report generation
			final String timestamp = DateUtils.format(new Date(), DateUtils.ISO8601_DATETIME_PATTERN);
			writer.writeAttribute(ATTR_TIMESTAMP, timestamp);
			writer.writeAttribute(ATTR_NUM_TESTS, String.valueOf(numTestsRun.longValue()));
			writer.writeAttribute(ATTR_NUM_FAILURES, String.valueOf(numTestsFailed.longValue()));
			writer.writeAttribute(ATTR_NUM_ERRORS, String.valueOf(numTestsErrored.longValue()));
			writer.writeAttribute(ATTR_NUM_SKIPPED, String.valueOf(numTestsSkipped.longValue()));
			writer.writeAttribute(ATTR_NUM_ABORTED, String.valueOf(numTestsAborted.longValue()));

			// write the properties
			writeProperties(writer);
			// write the tests
			writeTestCase(writer);
			writeSysOut(writer);
			writeSysErr(writer);
			// end the testsuite
			writer.writeEndElement();
		}

		void writeProperties(final XMLStreamWriter writer) throws XMLStreamException {
			final Properties properties = LegacyXmlResultFormatter.this.context.getProperties();
			if (properties == null || properties.isEmpty()) {
				return;
			}
			writer.writeStartElement(ELEM_PROPERTIES);
			for (final String prop : properties.stringPropertyNames()) {
				writer.writeStartElement(ELEM_PROPERTY);
				writer.writeAttribute(ATTR_NAME, prop);
				writer.writeAttribute(ATTR_VALUE, properties.getProperty(prop));
				writer.writeEndElement();
			}
			writer.writeEndElement();
		}

		void writeTestCase(final XMLStreamWriter writer) throws XMLStreamException {
			for (final Map.Entry<TestIdentifier, Stats> entry : testIds.entrySet()) {
				final TestIdentifier testId = entry.getKey();
				if (!testId.isTest() && isSuccessful(testId)) {
					// only interested in test methods and in failing containers
					continue;
				}
				// find the parent class of this test method
				final Optional<TestIdentifier> parent = testPlan.getParent(testId);
				if (!parent.isPresent()) {
					continue;
				}

				writer.writeStartElement(ELEM_TESTCASE);
				if (testId.isTest()) {
					final String classname = parent.get().getLegacyReportingName();
					writer.writeAttribute(ATTR_CLASSNAME, classname);
					writer.writeAttribute(ATTR_NAME, testId.getDisplayName());
				} else { // is a container
					writer.writeAttribute(ATTR_CLASSNAME, testId.getDisplayName());
					writer.writeAttribute(ATTR_NAME, "");
				}
				final Stats stats = entry.getValue();
				writer.writeAttribute(ATTR_TIME, String.valueOf((stats.endedAt - stats.startedAt) / ONE_SECOND));
				// skipped element if the test was skipped
				writeSkipped(writer, testId);
				// failed element if the test failed
				writeFailed(writer, testId);
				// errored element if the test failed with an error
				writeErrored(writer, testId);
				// aborted element if the test was aborted
				writeAborted(writer, testId);

				writer.writeEndElement();
			}
		}

		private boolean isSuccessful(final TestIdentifier testId) {
			return !aborted.containsKey(testId)//
					&& !failed.containsKey(testId) //
					&& !errored.containsKey(testId)//
					&& !skipped.containsKey(testId);
		}

		private void writeSkipped(final XMLStreamWriter writer, final TestIdentifier testIdentifier)
				throws XMLStreamException {
			if (!skipped.containsKey(testIdentifier)) {
				return;
			}
			writer.writeStartElement(ELEM_SKIPPED);
			final Optional<String> reason = skipped.get(testIdentifier);
			if (reason.isPresent()) {
				writer.writeAttribute(ATTR_MESSAGE, reason.get());
			}
			writer.writeEndElement();
		}

		private void writeFailed(final XMLStreamWriter writer, final TestIdentifier testIdentifier)
				throws XMLStreamException {
			writeIfPresentInMap(writer, testIdentifier, ELEM_FAILURE, failed, true);
		}

		private void writeErrored(final XMLStreamWriter writer, final TestIdentifier testIdentifier)
				throws XMLStreamException {
			writeIfPresentInMap(writer, testIdentifier, ELEM_ERROR, errored, true);
		}

		private void writeAborted(final XMLStreamWriter writer, final TestIdentifier testIdentifier)
				throws XMLStreamException {
			writeIfPresentInMap(writer, testIdentifier, ELEM_ABORTED, aborted, false);
		}

		private static void writeIfPresentInMap(XMLStreamWriter writer, TestIdentifier testIdentifier,
				String elemFailure, Map<TestIdentifier, Optional<Throwable>> map, boolean writeExceptionStackTrace)
				throws XMLStreamException {
			if (!map.containsKey(testIdentifier)) {
				return;
			}
			writer.writeStartElement(elemFailure);
			final Optional<Throwable> cause = map.get(testIdentifier);
			if (cause.isPresent()) {
				final Throwable t = cause.get();
				final String message = t.getMessage();
				if (message != null && !message.trim().isEmpty()) {
					writer.writeAttribute(ATTR_MESSAGE, message);
				}
				writer.writeAttribute(ATTR_TYPE, t.getClass().getName());

				if (writeExceptionStackTrace) {
					writer.writeCharacters(ExceptionUtils.readStackTrace(t));
				}
			}
			writer.writeEndElement();
		}

		private void writeSysOut(final XMLStreamWriter writer) throws XMLStreamException, IOException {
			if (!LegacyXmlResultFormatter.this.hasSysOut()) {
				return;
			}
			writer.writeStartElement(ELEM_SYSTEM_OUT);
			try (final Reader reader = LegacyXmlResultFormatter.this.getSysOutReader()) {
				writeCharactersFrom(reader, writer);
			}
			writer.writeEndElement();
		}

		private void writeSysErr(final XMLStreamWriter writer) throws XMLStreamException, IOException {
			if (!LegacyXmlResultFormatter.this.hasSysErr()) {
				return;
			}
			writer.writeStartElement(ELEM_SYSTEM_ERR);
			try (final Reader reader = LegacyXmlResultFormatter.this.getSysErrReader()) {
				writeCharactersFrom(reader, writer);
			}
			writer.writeEndElement();
		}

		private void writeCharactersFrom(final Reader reader, final XMLStreamWriter writer)
				throws IOException, XMLStreamException {
			final char[] chars = new char[1024];
			int numRead = -1;
			while ((numRead = reader.read(chars)) != -1) {
				// although it's called a DOMElementWriter, the encode method is just a
				// straight forward XML util method which doesn't concern about whether
				// DOM, SAX, StAX semantics.
				// TODO: Perhaps make it a static method
				final String encoded = new DOMElementWriter().encode(new String(chars, 0, numRead));
				writer.writeCharacters(encoded);
			}
		}

		private String determineTestSuiteName() {
			// this is really a hack to try and match the expectations of the XML report in
			// JUnit4.x
			// world. In JUnit5, the TestPlan doesn't have a name and a TestPlan (for which
			// this is a
			// listener) can have numerous tests within it
			final Set<TestIdentifier> roots = testPlan.getRoots();
			if (roots.isEmpty()) {
				return "UNKNOWN";
			}
			for (final TestIdentifier root : roots) {
				final Optional<ClassSource> classSource = findFirstClassSource(root);
				if (classSource.isPresent()) {
					return classSource.get().getClassName();
				}
			}
			return "UNKNOWN";
		}

		private Optional<ClassSource> findFirstClassSource(final TestIdentifier root) {
			if (root.getSource().isPresent()) {
				final TestSource source = root.getSource().get();
				if (source instanceof ClassSource) {
					return Optional.of((ClassSource) source);
				}
			}
			for (final TestIdentifier child : testPlan.getChildren(root)) {
				final Optional<ClassSource> classSource = findFirstClassSource(child);
				if (classSource.isPresent()) {
					return classSource;
				}
			}
			return Optional.empty();
		}
	}

}
